查看原文
其他

详解 TypeScript 函数声明和重载

CUGGZ 前端充电宝 2022-07-21

这是深入浅出TypeScript系列的第四篇——函数类型和函数重载。在 JavaScript 中,函数是构建应用的一块基石,我们可以使用函数抽离可复用的逻辑、抽象模型、封装过程。在TypeScript中,函数仍然是最基本、最重要的概念之一。下面就来看看TypeScript中的函数类型是如何定义和使用的。

一、函数类型定义

1. 直接定义

函数类型的定义包括对参数返回值的类型定义:

function add(arg1: number, arg2: number): number {
  return x + y;
}
const add = (arg1: number, arg2: number): number => {
  return x + y;
};

这里用function字面量箭头函数两种形式定义了add函数。函数参数 arg1 和 arg2 都是数值类型,最后通过相加得到的结果也是数值类型。

如果在这里省略参数的类型,TypeScript 会默认这个参数是 any 类型;如果省略返回值的类型,如果函数无返回值,那么 TypeScript 会默认函数返回值是 void 类型;如果函数有返回值,那么 TypeScript 会根据定义的逻辑推断出返回类型。

需要注意,在TypeScript中,如果函数没有返回值,并且我们显式的定义了这个函数的返回值类型为 undefined,那就会报错:A function whose declared type is neither 'void' nor 'any' must return a value。正确的做法就是上面说的,将函数的返回值类型声明为void:

function fn(x: number): void {
 console.log(x)
}

一个函数的定义包括函数名、参数、逻辑和返回值。为函数定义类型时,完整的定义应该包括参数类型和返回值类型。上面都是在定义函数的指定参数类型和返回值类型。下面来定义一个完整的函数类型,以及用这个函数类型来规定一个函数定义时参数和返回值需要符合的类型。

let add: (x: number, y: number) => number;
add = (arg1: number, arg2: number): number => arg1 + arg2;
add = (arg1: string, arg2: string): string => arg1 + arg2; // error

这里定义了一个变量 add,给它指定了函数类型,也就是(x: number, y: number) => number,这个函数类型包含参数和返回值的类型。然后给 add 赋了一个实际的函数,这个函数参数类型和返回类型都和函数类型中定义的一致,所以可以赋值。后面又给它赋了一个新函数,而这个函数的参数类型和返回值类型都是 string 类型,这时就会报如下错误:

不能将类型"(arg1: string, arg2: string) => string"分配给类型"(x: number, y: number) => number"
参数"arg1""x" 的类型不兼容。
不能将类型"number"分配给类型"string"

注意: 函数中如果使用了函数体之外定义的变量,这个变量的类型是不体现在函数类型定义的。

2. 接口定义

使用接口可以清晰地定义函数类型。下面来使用接口为add函数定义函数类型:

interface Add {
  (x: number, y: number): number;
}
let add: Add = (arg1: string, arg2: string): string => arg1 + arg2; 
// error 不能将类型“(arg1: string, arg2: string) => string”分配给类型“Add”

通过接口形式定义了函数类型,这个接口Add定义了这个结构是一个函数,两个参数类型都是number类型,返回值也是number类型。当指定变量add类型为Add时,再要给add赋值,就必须是一个函数,且参数类型和返回值类型都要满足接口Add,显然这个函数并不满足条件,所以报错了。

3. 类型别名定义

可以使用类型别名来定义函数类型,这种形式更加直观易读:

type Add = (x: number, y: number) => number;
let add: Add = (arg1: string, arg2: string): string => arg1 + arg2; 
// error 不能将类型“(arg1: string, arg2: string) => string”分配给类型“Add”

使用type关键字可以给任何定义的类型起一个别名。上面定义了 Add 这个别名后,Add就成为了一个和(x: number, y: number) => number一致的类型定义。上面定义了Add类型,指定add类型为Add,但是给add赋的值并不满足Add类型要求,所以报错了。

注意,这里的=>与 ES6 中箭头函数的=>不同。TypeScript 函数类型中的=>用来表示函数的定义,其左侧是函数的参数类型,右侧是函数的返回值类型;而 ES6 中的=>是函数的实现。

二、函数参数定义

1. 可选参数

TypeScript 会在编写代码时就检查出调用函数时参数中存在的一些错误:

type Add = (x: number, y: number) => number;
let add: Add = (arg1, arg2) => arg1 + arg2;
add(12);    // success
add(123); // error 应有 2 个参数,但获得 3 个
add(1);       // error 应有 2 个参数,但获得 1 个

在JavaScript中,上面代码中后面两个函数调用都不会报错, 只不过add(1, 2, 3)可以返回正确结果3add(1)会返回NaN。而在TypeScript中我们设置了指定的参数,那么在使用该类型时,传入的参数必须与定义的参数类型和数量一致。

但有时候,函数有些参数不是必须的,我们就可以将函数的参数设置为可选参数。可选参数只需在参数名后跟随一个?即可:

type Add = (x: number, y: number, z?: number) => number;
let add: Add = (arg1, arg2, arg3) => arg1 + arg2 + arg3;
add(12);    // success   3
add(123); // success   6

上面的代码中,z是一个可选参数,那他的类型就是number | undefined,表示参数 z 就是可缺省的,那是不是意味着可缺省和类型是 undefined 等价呢?来看下面的例子:

function log(x?: number) {
  console.log(x);
}
function log1(x: number | undefined) {
  console.log(x);
}
log();
log(undefined);
log1();    // Expected 1 arguments, but got 0
log1(undefined);

可以看到,第三次函数调用报错了,这里的 ?: 表示在调用函数时可以不显式的传入参数。但是,如果声明了参数类型为 number | undefined,就表示函数参数是不可缺省且类型必须是 number 或者 undfined。

需要注意,可选参数必须放在必选参数后面,这和在 JS 中定义函数是一致的。来看例子:

type Add = (x?: number, y: number) => number;  // error 必选参数不能位于可选参数后。

在TypeScript中,可选参数必须放到最后,上面把可选参数x放到了必选参数y前面,所以报错了。在 JavaScript 中是没有可选参数这个概念的,只不过在编写逻辑时,可能会判断某个参数是否为undefined,如果是则说明调用该函数的时候没有传这个参数,要做下兼容处理;而如果几个参数中,前面的参数是可不传的,后面的参数是需要传的,就需要在该可不传的参数位置传入一个 undefined 占位才行。

2. 默认参数

在 ES6 标准出来之前,默认参数实现起来比较繁琐:

var count = 0;
function counter(step) {
  step = step || 1;
  count += step;
}

上面定义了一个计数器增值函数,这个函数有一个参数 step,即每次增加的步长,如果不传入参数,那么 step 接受到的就是 undefined,undefined 转换为布尔值是 false,所以 step || 1 这里取了 1,从而达到了不传参数默认 step === 1 的效果。

在 ES6 中,定义函数时给参数设默认值直接在参数后面使用等号连接默认值即可:

const count = 0;
const counter = (step = 1) => {
  count += step;
};

当为参数指定了默认参数时,TypeScript 会识别默认参数的类型;当调用函数时,如果给这个带默认值的参数传了别的类型的参数则会报错:

const add = (x: number, y = 2) => {
  return x + y;
};
add(1"ts"); // error 类型"string"的参数不能赋给类型"number"的参数

当然也可以显式地给默认参数 y 设置类型:

const add = (x: number, y: number = 2) => {
  return x + y;
};

注意:函数的默认参数类型必须是参数类型的子类型,如下代码:

const add = (x: number, y: number | string = 2) => {
  return x + y;
};

这里 add 函数参数 y 的类型为可选的联合类型 number | string,但是因为默认参数数字类型是联合类型 number | string 的子类型,所以 TypeScript 也会检查通过。

3. 剩余参数

在 JavaScript 中,如果定义一个函数,这个函数可以输入任意个数的参数,那么就无法在定义参数列表的时候挨个定义。在 ES6 发布之前,需要用 arguments 来获取参数列表。arguments 是一个类数组对象,它包含在函数调用时传入函数的所有实际参数,它还包含一个 length 属性,表示参数个数。下面来模拟实现函数的重载:

function handleData() {
  if (arguments.length === 1return arguments[0] * 2;
  else if (arguments.length === 2return arguments[0] * arguments[1];
  else return Array.prototype.slice.apply(arguments).join("_");
}
handleData(2); // 4
handleData(23); // 6
handleData(12345); // '1_2_3_4_5'

这段代码如果在TypeScript环境中执行,三次handleData的调用都会报错,因为handleData函数定义的时候没有参数。

在 ES6 中,加入了拓展运算符,它可以将一个函数或对象进行拆解。它还支持用在函数的参数列表中,用来处理任意数量的参数:

const handleData = (arg1, ...args) => {
  console.log(args);
};
handleData(12345); // [ 2, 3, 4, 5 ]

在 TypeScript 中可以为剩余参数指定类型:

const handleData = (arg1: number, ...args: number[]) => {

};
handleData(1"a"); // error 类型"string"的参数不能赋给类型"number"的参数

三、函数重载

在多数的函数中,是只能接受一组固定的参数。但是一些函数可以接收可变数量的参数、不同类型的参数,甚至可以根据调用函数的方式来返回不同类型的参数。要使用此类函数,TypeScript我们提供了函数重载功能。下面来看看函数重载是如何工作的。

1. 函数签名

先来看一个简单的例子:

function greet(person: string): string {
  return `Hello, ${person}!`;
}

greet('World'); // 'Hello, World!'

这里的greet方法接收一个参数name,类型为string。那如果想让greet方法来接收一组名称怎么办?那这时greet函数会接收字符串或字符串数组作为参数,并返回字符串或字符串数组。那该如何改造这个函数?主要有两种方式:通过判断参数类型来修改函数签名

function greet(person: string | string[]): string | string[] {
  if (typeof person === 'string') {
    return `Hello, ${person}!`;
  } else if (Array.isArray(person)) {
    return person.map(name => `Hello, ${name}!`);
  }
  throw new Error('error');
}

greet('World');          // 'Hello, World!'
greet(['TS''JS']);     // ['Hello, TS!', 'Hello, JS!']

这是最简单直接的方式,但是在某些情况下,我们希望单独定义调用函数的方式,这时就可以使用函数重载。

2. 函数重载

当修改函数签名的方式比较复杂或者涉及到多种数据类型时,建议使用函数重载来完成。在函数重载中,我们需要定义重载签名和实现签名。重载函数签名只定义函数的参数和返回值类型,并不会定义函数的正文。对于一个函数不同的调用方式,就可以有多个重载签名。

下面来实现greet()函数的重载:

// 重载签名
function greet(person: string): string;
function greet(persons: string[]): string[];
 
// 实现签名
function greet(person: unknown): unknown 
{
  if (typeof person === 'string') {
    return `Hello, ${person}!`;
  } else if (Array.isArray(person)) {
    return person.map(name => `Hello, ${name}!`);
  }
  throw new Error('error');
}

这里greet()函数有两个重载签名和一个实现签名。每个重载签名都描述了调用函数的一种方式。我们可以使用字符串参数或使用字符串参数数组来调用greet()函数。

现在就可以使用字符串或字符串数组的参数调用greet()):

greet('World');          // 'Hello, World!'
greet(['TS''JS']);     // ['Hello, TS!', 'Hello, JS!']

在定义函数重载时,需要注意以下两点:

(1)函数签名是可以调用的

虽然上面我们定义了重载签名和签名方法,但是签名实现时不能直接调用的,只有重载签名可以调用:

const someValue: unknown = 'Unknown';
greet(someValue);

这样调用的话就会报错:

No overload matches this call.
Overload 1 of 2'(person: string): string', gave the following error.
 Argument of type 'unknown' is not assignable to parameter of type 'string'.
Overload 2 of 2'(persons: string[]): string[]', gave the following error.
  Argument of type 'unknown' is not assignable to parameter of type 'string[]'

也就是说,即使签名实现接收unknown类型的参数,但是我们不能直接给greet()方法来传递unknown类型的参数,参数只能是函数重载签名中定义的参数类型。

(2)实现签名必须是通用的

在实现签名时,定义的数据类型需要是通用的,以包含重载签名。假如我们把greet()方法的返回值类型定义为string,这时就会出问题了:

function greet(person: string): string;
function greet(persons: string[]): string[];

function greet(person: unknown): string 
{
  // ...
  throw new Error('error');
}

此时string[]类型就会和string不兼容。所以,实现签名的返回类型和参数类型都要包含所有重载签名中的参数类型和返回值类型,保证是通用的。

3. 方法重载

除了常规函数外,类中的方法也可以过载,比如用重载方法greet()来实现一个类:

class Greeter {
  message: string;
 
  constructor(message: string) {
    this.message = message;
  }
 
  greet(person: string): string;
  greet(persons: string[]): string[];

  greet(person: unknown): unknown {
    if (typeof person === 'string') {
      return `${this.message}, ${person}!`;
    } else if (Array.isArray(person)) {
      return person.map(name => `${this.message}, ${name}!`);
    }
    throw new Error('error');
  }
}

Greeter类中包含了greet()重载方法:这里面有两个描述如何调用方法的重载签名,以及包含其实现签名。这样我们就可以通过两种方式调用hi.greet():

const hi = new Greeter('Hi');
 
hi.greet('World');          // 'Hello, World!'
hi.greet(['TS''JS']);     // ['Hello, TS!', 'Hello, JS!']


推荐阅读:






如果觉得有用,就点个赞和再看吧~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存